Skip to content

cubeapi: key the rate limiter on the validated identity, not an unvalidated header - #1380

Open
dwin-gharibi wants to merge 7 commits into
TencentCloud:masterfrom
dwin-gharibi:cubeapi-ratelimit-key-bypass
Open

cubeapi: key the rate limiter on the validated identity, not an unvalidated header#1380
dwin-gharibi wants to merge 7 commits into
TencentCloud:masterfrom
dwin-gharibi:cubeapi-ratelimit-key-bypass

Conversation

@dwin-gharibi

Copy link
Copy Markdown
Contributor

Closes #1379.

Motivation

The limiter bucketed on the raw X-API-Key header. Because extract_credential prefers
Authorization: Bearer, that header is never validated when a Bearer token is present — but it still
chose the bucket. Rotating it therefore gave a fresh full-quota bucket per request and the limit never
applied. Clients sending no X-API-Key all shared a single "anonymous" bucket, so in callback
(multi-tenant) mode any one tenant could starve the rest. And the keyed state store was never reclaimed.

What this changes

1. middleware/auth.rs — the auth middleware publishes the identity it validated. A new
RateLimitIdentity(String) is inserted into the request extensions once the credential has been accepted,
in both modes:

  • simple-key mode: after the key comparison succeeds
  • callback mode: after the callback returns 200

The identity is prefixed (bearer: / apikey:) so the two credential kinds cannot collide.

2. middleware/rate_limit.rs — the limiter reads that extension instead of a header, falling back to
a single "unauthenticated" bucket if it is somehow absent. In practice it never is: rate_limit is only
ever layered together with unified_auth, and unified_auth runs first.

3. middleware/auth.rs — the API key comparison is now constant-time. provided != expected_key
short-circuits at the first differing byte, which leaks the key one byte at a time to a patient attacker.
Replaced with a length-check plus an XOR-accumulate loop. CubeOps already does this
(subtle.ConstantTimeCompare), so the two services now agree.

4. state.rs — a background task calls rate_limiter.retain_recent() every 60s, so the DashMap no
longer grows without bound.

No comment changes.

Testing

Re-ran the probe that originally demonstrated the bypass, against the real binary with
CUBE_API_KEY=supersecret --rate-limit-per-sec 3, 30 requests per case:

case master this branch
A) Bearer only 26 × 429 27 × 429
B) Bearer + rotating unvalidated X-API-Key 0 × 429 26 × 429
C) valid X-API-Key only 27 × 429

Case B is the fix. Case C confirms API-key clients are still limited, and case A that Bearer clients are
too — all three shapes now behave the same.

CubeAPI's own suite:

$ cargo test
test result: ok. 109 passed; 0 failed; 0 ignored

CI gates checked locally:

  • cargo fmt --check — clean (fmt-check).
  • cargo build — clean.
  • cargo clippy --all-targets — no new warnings. The two field_reassign_with_default hits reported in
    auth.rs are the pre-existing ones in that file's test module; their line numbers moved because this
    change inserts code above them.

Behaviour change worth noting

Bearer clients are now limited per token rather than sharing one global bucket. That is the intended
behaviour, but it does mean a deployment that was unknowingly relying on the shared-bucket accounting will
see different 429 patterns. Concretely: a single Bearer client that previously consumed the shared quota
now gets its own, so aggregate throughput across many Bearer clients goes up, while an individual abusive
client is now actually constrained.

Not fixed here

extract_credential matches the Bearer scheme case-sensitively, so a spec-compliant
authorization: bearer <token> is rejected (RFC 7235 §2.1 makes the scheme case-insensitive). I confirmed
it still reproduces on this branch (5/5 requests → 401) and left it alone: it is a separate defect with its
own issue, and mixing an interop fix into a rate-limiting fix would muddy both.

Copilot AI lite review requested due to automatic review settings August 18, 2026 06:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

.and_then(|v| v.to_str().ok())
.unwrap_or("anonymous")
.to_string();
.extensions()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale doc comments now that the key comes from the validated identity, not the header. The module doc above still reads: "Per-API-key token bucket rate limiter middleware. Reads the X-API-Key header and checks the shared governor limiter." That's no longer accurate — this now keys on the RateLimitIdentity extension published by unified_auth (which is itself keyed on the validated credential). Same for AppState::rate_limiter's "Per-API-key rate limiter" field doc. Worth a one-line update so the docs don't mislead a future reader into re-adding header-based keying.

@cubesandboxbot

cubesandboxbot Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review — #1380: cubeapi: key the rate limiter on the validated identity, not an unvalidated header

AI-generated review (static analysis; the PR head was not built or executed in this session — no blocking issues found, findings are low-severity nits).

Verdict: approve

This is a well-scoped and correctly-implemented fix for a real security/correctness bug. The limiter previously bucketed on the raw X-API-Key header, which is never validated when Authorization: Bearer is present — so rotating the unvalidated header handed out a fresh full-quota bucket per request (case B in the PR description), and in callback (multi-tenant) mode every Bearer-only client collapsed into one shared "anonymous" bucket, letting any one tenant starve the rest. Publishing the validated identity from unified_auth and reading it in rate_limit closes both gaps, and the new tests cover each attack shape: rotating unvalidated header, bearer-only, alternating header styles, and distinct callback tenants.

I verified the invariants the change relies on:

  • Layer ordering is correct. with_auth_and_rate_limit applies .layer(rate_limit) then .layer(unified_auth); axum/tower apply the last-added layer outermost, so unified_auth runs first, inserts RateLimitIdentity, and rate_limit observes it. rate_limit is only ever layered through this helper (sandbox/snapshot routes), so every request that reaches it has passed unified_auth.
  • Every allow path publishes an identity. Simple-key mode inserts after the comparison succeeds; callback mode inserts only on a 200; the only passthrough without an identity is no-auth mode (both configs unset), where rate_limit is not layered. The debug_assert therefore cannot legitimately fire.
  • "configured-key" (simple-key mode) is correct semantics. With a single configured key there is one identity, so one global bucket. This also fixes a related doubling bug the tests cover: previously Bearer clients landed in the "anonymous" bucket and X-API-Key clients in the key-value bucket, so a client alternating header styles split traffic across two independent pools.
  • The GC does not weaken the limit. Reclaiming an idle key is equivalent to the refill a token bucket would have performed anyway; an actively-throttled key stays in the map and stays throttled (the new retain_recent_does_not_hand_an_active_key_a_fresh_bucket test pins exactly this property).

Findings (inline)

  1. Hand-rolled constant-time comparisonmiddleware/auth.rs:65 (constant_time_eq). The XOR-accumulate is the standard pattern and correct in practice, but LLVM is permitted to optimize it (the intermediate diff values are unobservable, so an early-exit transform is not ruled out). Since the PR explicitly aims to match CubeOps' subtle.ConstantTimeCompare, using subtle::ConstantTimeEq (the crate is already in the dependency tree) would give the same black_box-backed guarantee.
  2. GC task spawned unconditionallystate.rs:41. The sweep runs for every AppState, including no-auth deployments where rate_limit is never layered and the limiter is never consulted. Consider gating on config.auth_is_configured(). Also, tokio::time::interval's first tick fires immediately, so retain_recent runs once at startup.
  3. retain_recent reclaim window ≠ sweep intervalstate.rs:66. retain_recent retains keys seen within the quota's replenish period (Quota::per_second(n) → 1/n s), not within the 60 s sweep. For the default 100/s that is 10 ms, so each sweep effectively resets essentially all idle buckets to full. Benign for token-bucket semantics, but the memory bound is really controlled by the replenish period, and the behavior differs from the "sweep every 60s" framing.

Design considerations (no action required)

  • Callback-mode buckets are per-token, so token rotation resets the quota. A tenant that can mint fresh tokens (e.g., short-lived JWTs) effectively gets a fresh bucket per token. This is the intended per-validated-identity trade-off and strictly better than the old shared "anonymous" bucket, but the limit is per-token rather than per-tenant for rotating credentials.
  • The rate_limitunified_auth coupling is implicit (an Extension lookup). The debug_assert catches a violation in debug builds; in release, the fallback silently collapses all traffic into one "unauthenticated" bucket — a safe failure, but worth keeping in mind if the layering is ever reworked.
  • Simple-key mode is now one global bucket for all clients (previously two pools: "anonymous" for Bearer + the key value for X-API-Key). This is the intended fix and is covered by alternating_header_styles_share_one_bucket_in_simple_key_mode, but it is a behavior change for deployments mixing header styles.
  • Cosmetic: the sha2 dependency in Cargo.toml is placed under the # ── High-concurrency in-memory state ── comment block, which doesn't describe it.
  • Test robustness: the > 20-throttled assertions against a 3/s limiter leave a large margin (in-process requests are well under the ~78 ms/request budget). The callback test performs a real HTTP round-trip per request, so its margin is somewhat tighter, but flakiness risk remains low.

Verification notes

Reviewed against the base-branch workspace (master) and review-input/pr.diff; review-input/TRUNCATED is absent (full diff present). Verified: retain_recent/len/DefaultKeyedRateLimiter exist in the pinned governor 0.6.3; sha2 0.10.9 produces 64-hex output via {:x} (matches the length assertions); AppError::TooManyRequests maps to 429; subtle 2.6.1 is in the lock. Did not compile or run the test suite (no shell available this session).

Comment thread CubeAPI/src/middleware/rate_limit.rs
Comment thread CubeAPI/src/state.rs
Comment thread CubeAPI/src/middleware/auth.rs Outdated
Comment thread CubeAPI/src/middleware/auth.rs
@liciazhu

Copy link
Copy Markdown
Collaborator

LGTM. I've verified the fix empirically with governor 0.6.3 — the rotating-header bypass is closed (keying on the validated identity), and the GC doesn't reset active buckets. The remaining points are non-blocking; I'd just suggest adding a GC regression test when you have a chance. Thanks!

…e validated identity, not an unvalidated header

Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
…key and correct the limiter docs

Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
… router level

Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
…irst and correct the per-identity docs

Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
…isolation

Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
…ad of storing raw credentials

Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
…s and reclaiming idle keys

Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
@dwin-gharibi
dwin-gharibi force-pushed the cubeapi-ratelimit-key-bypass branch from f05fdd4 to 5f48af7 Compare August 19, 2026 08:16
Comment thread CubeAPI/src/middleware/auth.rs
Comment thread CubeAPI/src/state.rs
Comment thread CubeAPI/src/state.rs
@dwin-gharibi

Copy link
Copy Markdown
Contributor Author

Everything is fine now @liciazhu.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug Report] CubeAPI rate limiter is bypassable with an unvalidated X-API-Key, and lumps all Bearer clients into one bucket

3 participants